How java app runs
contents
이 과정은 소스 코드(Source Code) 가 바이트코드(Bytecode) 로 변환되고, 최종적으로 JVM(Java Virtual Machine) 내부에서 네이티브 기계어(Native Machine Code) 로 바뀌는 여정입니다.
실행 수명 주기를 단계별로 나누어 보겠습니다.
1단계: 컴파일 타임 (javac 프로세스)
애플리케이션이 실행되기 전, 변환 과정이 필요합니다. CPU는 기계어(0과 1)만 이해하지만, Java는 먼저 바이트코드라는 중간 포맷으로 컴파일됩니다.
- 어휘 분석 (Lexical Analysis): 컴파일러가
.java파일을 읽고 토큰(키워드public,class, 식별자, 연산자 등) 단위로 분해합니다. - 구문 및 의미 분석 (Syntax & Semantic Analysis): 추상 구문 트리(AST)를 생성하여 자바 문법 규칙을 따르는지 확인합니다 (예: 변수 선언 후 사용 여부 확인).
- 바이트코드 생성 (Bytecode Generation): 컴파일러가 AST를 바이트코드(16진수 opcode)가 담긴
.class파일로 변환합니다.- 참고: 이 코드는 플랫폼 독립적입니다. 아직 윈도우에서 실행될지 리눅스에서 실행될지 모르는 상태입니다.
2단계: JVM 시작 및 로딩
java com.example.MyProject 명령어를 실행하면 운영체제(OS)가 JVM을 시작합니다.
1. 클래스로더 서브시스템 (ClassLoader Subsystem)
JVM은 .class 파일을 메모리로 가져와야 합니다. 이때 위임 모델(Delegation Model) 을 사용합니다:
- 부트스트랩 클래스로더 (Bootstrap ClassLoader): 핵심 Java 라이브러리(예:
java.lang.String등rt.jar에 있는 것들)를 네이티브 코드를 사용해 로드합니다. - 플랫폼/확장 클래스로더 (Platform/Extension ClassLoader): 확장 기능들을 로드합니다.
- 애플리케이션 클래스로더 (Application ClassLoader): 클래스패스(classpath)에 있는 여러분의 코드를 로드합니다.
2. 링킹 (Linking)
로딩된 클래스는 세 가지 중요한 단계를 거칩니다:
- 검증 (Verification): 바이트코드 검증기(Bytecode Verifier) 가 파일의 보안 위험(스택 오버플로우, 잘못된 데이터 타입 변환 등)을 검사합니다. 실패하면
VerifyError가 발생합니다. - 준비 (Preparation): JVM이 static 변수를 위한 메모리를 할당하고 기본값(default value)을 지정합니다. (예:
static int x는 아직 할당한 값이 아닌0으로 설정됨). - 해석 (Resolution): 심볼릭 참조(다른 클래스나 메서드의 이름)를 실제 메모리 주소 참조로 교체합니다.
3. 초기화 (Initialization)
로딩의 마지막 단계입니다. JVM은 <clinit> 메서드(클래스 초기화)를 실행합니다.
- static 블록 (
static { ... })을 실행합니다. - static 변수에 실제 값을 할당합니다 (예:
static int x = 10이라면 이제 실제로 10이 됩니다).
3단계: 메모리 할당 (런타임 데이터 영역)
main 메서드가 시작되기 전, JVM은 메모리 영역을 배치합니다:
| 메모리 영역 | 설명 |
|---|---|
| 메서드 영역 (Method Area) | 클래스 구조(메타데이터), 상수, static 변수가 저장됩니다. |
| 힙 (Heap) | **객체(Object)**가 사는 곳입니다. new Object()를 하면 여기 생성됩니다. |
| 스택 (Stack) | 지역 변수와 메서드 호출을 저장합니다. 각 스레드는 자신만의 스택을 가집니다. |
| PC 레지스터 | 현재 실행 중인 명령어 주소를 추적합니다. |
| 네이티브 스택 | 네이티브 C/C++ 메서드(JNI) 실행에 사용됩니다. |
4단계: main 메서드 실행
이제 JVM이 진입점(Entry point)을 실행할 준비가 되었습니다.
1. 스택 프레임 생성
JVM은 새로운 스레드(메인 스레드)를 생성하고 public static void main(String[] args)를 찾습니다.
- 메인 스레드의 스택에 새로운 스택 프레임(Stack Frame) 을 올립니다(Push).
- 이 프레임은
args배열과main내부의 지역 변수들을 담고 있습니다.
2. 실행 엔진 (Execution Engine)
JVM은 바이트코드 명령어를 하나씩 읽습니다. 실행에는 하이브리드 방식을 사용합니다:
- 인터프리터 (Interpreter): 바이트코드 명령어를 읽고 즉시 OS/CPU 시스템 콜로 "통역(interpret)"하여 실행합니다. 시작은 빠르지만 반복문 등에서는 느릴 수 있습니다.
- JIT (Just-In-Time) 컴파일러:
인터프리터가 도는 동안 JVM은 코드를 모니터링합니다. 특정 메서드(혹은 루프)가 자주 호출되면 "핫스팟(Hot Spot)" 으로 표시합니다.
- JIT 컴파일러는 이 특정 바이트코드를 해당 CPU에 최적화된 네이티브 기계어로 컴파일합니다.
- 다음에 그 코드가 실행될 때는 인터프리터를 건너뛰고 네이티브 코드를 직접 실행하므로 속도가 매우 빠릅니다.
3. 실행 예시
만약 main 메서드에 int a = 10 + 20;이 있다면, 엔진은 다음과 같이 수행합니다:
bipush 10: 10을 오퍼랜드 스택(Operand Stack) 에 넣습니다.bipush 20: 20을 오퍼랜드 스택에 넣습니다.iadd: 위 두 값을 꺼내 CPU의 ALU(산술 논리 장치)로 더한 뒤, 결과(30)를 다시 스택에 넣습니다.istore_1: 결과를 꺼내 지역 변수 배열 1번 인덱스(변수a)에 저장합니다.
5단계: 정리 및 종료
- 가비지 컬렉션 (GC): 프로그램이 실행되면서 힙(Heap)에 생성된 객체 중 더 이상 참조되지 않는 것은 가비지 컬렉터(데몬 스레드)가 메모리를 회수합니다.
- 종료 (Exit): 다음 조건이 되면 JVM은 실행을 멈춥니다:
main메서드가 끝났을 때.- 모든 사용자 스레드(비-데몬 스레드)가 종료되었을 때.
System.exit()이 호출되었을 때.
이 시점에 JVM은 종료되며 메모리를 운영체제에 반환합니다.
요약 흐름
- 소스 (
.java) -> 컴파일러 -> 바이트코드 (.class) - OS가 JVM 시작
- 클래스로더가 클래스를 찾아 로딩
- 검증기(Verifier) 가 코드 안전성 확인
- Static 초기화로 static 변수 설정
- 메인 스레드 생성 -> 스택 프레임 Push
- 인터프리터가 코드 실행 -> JIT가 핫스팟을 네이티브 코드로 최적화
- 프로그램 종료 -> JVM 종료
메서드 간에 객체를 주고받을 때 스택(Stack)과 힙(Heap)이 어떻게 상호작용하는지에 대해서도 설명해 드릴까요?
references